De datos en bruto a información útil para decidir con tidyverse
Dpto. de Fundamentos del Análisis Económico. Universidad de Alicante
El desafío real del análisis de datos:
80% del tiempo de trabajo “sucio” : limpieza y preparación
20% del tiempo: análisis y modelización
tidyverse incluye una colección de bibliotecas con herramientes eficientes para el proceso de “tratamiento de datos” (“data wrangling”)
El objetivo es obtener un conjunto de datos ordenado y limpio para realizar el análisis eficientemente y obtener información útil para la toma de decisiones
1.- Cada columna es una variable: mide el mismo atributo entre unidades
2.- Cada fila es una observación (caso): misma unidad a través de atributos
3.- Cada celda es un valor
Importante: ¿a qué nivel estamos midiendo la información? ¿qué es una unidad en la tabla?
a nivel de cada compra realizada por un cliente en cada fecha
a nivel de todas las compras realizdas por un cliente, etc.
Tenemos información similar y no redundante en una misma tabla
Otras estructuras como esta pueden tener sentido para mostrar información (o por convenciones)
La visualización es atractiva, PERO sobran filas para analizar los datos: ej., total de personas con hijos y sin pareja entre 30 y 39 años
Contexto: Cadena de venta al por menor con 12 tiendas en España
Objetivo: Analizar rendimiento de ventas para toma de decisiones estratégicas
La mayoría de operaciones pueden realizarse combinando 5 “verbos”:
select(): selecciona columnas (variables)
filter(): filtra (extraer) filas
mutate(): crea nuevas columnas
arrange(): ordena filas
summarize(): crea resúmenes de la tabla
Más la tubería %>% o |>
y group_by()
NOTA: existe una colección de “chuletas” de R, p.e., para transformación.
Todos tienen como primer argumento un data frame, los siguientes describen qué hacer (con columnas o filas) y devuelven otro data frame
select()filter()%>% o |>datos %>% filter(condicion) equivale a filter(datos, condicion)
El anidamiento con tuberías sigue el flujo natural de lectura
# Por rango de columnas
ventas |> select(id_venta:id_tienda)
# Excluir columnas
ventas |> select(-descuento_porcentaje, -descuento_aplicado)
# Por patrón de nombre
ventas |> select(starts_with("id_"))
ventas |> select(ends_with("_porcentaje"))
ventas |> select(contains("descuento"))
# Por tipo de dato
ventas |> select(where(is.numeric))
ventas |> select(where(is.character))pull(): extrae una única columna, como vectormutate()lubridate)arrange()desc()summarize()KPIs <- ventas |>
summarize(
total_ventas = n(), # Volumen (núm. de filas)
ingresos_totales = sum(total), # Ingresos
ingresos_promedio = mean(total),
ingresos_mediano = median(total),
descuento_promedio = mean(descuento_porcentaje), # Descuentos
descuento_total = sum(descuento_aplicado),
unidades_vendidas = sum(cantidad), # Productos
clientes_unicos = n_distinct(id_cliente) # Clientes
# (núm. de filas distintas)
)group_by(): Análisis por Gruposgroup_by() + summarize() = el poder de la agregación
Concepto: cambiar el nivel de análisis: de transacciones a tiendas, productos, etc.
En Excel: Tablas dinámicas, AGRUPARPOR() (y SUMAR.SI/SUMIF)
mutate() con group_by()Diferencia Clave:
group_by() + summarize(): Reduce filas (nuevo dataset agregado a nivel de los grupos)
group_by() + mutate(): Mantiene filas (añade columnas calculadas por grupo a nivel de la tabla original)
Ejemplo: Porcentaje de las ventas mensuales que representa cada transacción
ungroup()IMPORTANTE: No olvidar ungroup() o .groups = "drop" después de terminar operaciones agrupadas
Cálculo de Porcentajes Globales: sin desagrupar, sum(total) suma por tienda → siempre da 100%
ungroup() (cont.)slice(), slice_head()), aleatoriamente (slice_sample()), etc.distinct(): extrae sólo las filas únicas (una o varias variables)drop_na() y replace_na(): elimina/reemplaza filas con valores ausentestidyverseMuchas funciones son equivalentes a otras de R base:
parse_number(), parse_factor(), etc. por as.numeric(), as.factor(), etc.
bind_cols() y bind_rows() por cbind() y rbind()
if_else() y case_when() para ejecución condicional (ifelse())
cut_interval(), cut_number(), cut_width()lubridate: year(), month(), day(), quarter(), week()rename(): cambiar el nombre de una columnaacross(): aplica la misma transformación a múltiples columnasOperadores aritméticos (+, -, *, /, ^, %/%, %%) y lógicos (<, <=, >, >=, !=)
Funciones como log(), lag(), lead(), cumsum(), row_number() etc.
count(): cuenta los valores únicos de una o más variablesMedidas de centralidad y de dispersión: mean(x), median(x), sd(x), IQR(x)
Medidas de rango: min(x), quantile(x, 0.25), max(x)
Medidas de posición: first(x), nth(x, 2), last(x).
Sumas, productos, etc.
Conteos:
n(): observaciones totales (tamaño del grupo)
n_distinct(x): filas distintas en x
separate(): dividir una columna por caracter o posiciónconvert = TRUE intenta convertir el tipo de dato (no mantener carácter)unite(): combinar columnasFormato ANCHO:
| tienda | Q1 | Q2 | Q3 | Q4 |
|---|---|---|---|---|
| Madrid | 145 | 158 | 151 | 169 |
| Barcelona | 152 | 164 | 156 | 175 |
| Valencia | 138 | 151 | 149 | 162 |
(datos del informe enviado por gestores)
Formato LARGO:
| tienda | trimestre | ventas |
|---|---|---|
| Madrid | Q1 | 145 |
| Madrid | Q2 | 158 |
| Madrid | Q3 | 151 |
| Madrid | Q4 | 169 |
| Barcelona | Q1 | 152 |
| … | … | … |
Los trimestres son columnas
Cada tienda = 1 fila
Análisis de datos a menudo complicado
Los trimestres son valores
Cada tienda-trimestre = 1 fila
No adecuado para tablas de presentación final
pivot_longer(): girar de ancho a largotabla a cambiar de forma
nombres o índices (numéricos) de las columnas a girar: representan valores, no variables
nombre para la nueva variable que tendrá, como valores, esas antiguas columnas a girar
nombre para la nueva variable que tendrá como valores las antiguas celdas
pivot_wider(): girar de largo a anchotabla a cambiar de forma
nombre de la variable cuyos valores dan nombre a las nuevas columnas
nombre de la variable de cuyas celdas toman los valores las nuevas columnas
Según nuestro objetivos, podemos preferir formato ancho o largo
Problemas prácticos con formato ancho para analizar datos:
ggplot(ventas_ancho) +
geom_line(aes(x = 1:4, y = c(Q1[1], Q2[1], Q3[1], Q4[1])), color = "red") +
geom_line(aes(x = 1:4, y = c(Q1[2], Q2[2], Q3[2], Q4[2])), color = "blue") +
geom_line(aes(x = 1:4, y = c(Q1[3], Q2[3], Q3[3], Q4[3])), color = "green")
ggplot(ventas_largo, aes(x = trimestre, y = ventas,
color = tienda, group = tienda)) +
geom_line() + geom_point()Analizar datos suele implicar múltiples tablas
diferentes orígenes: ej., dptos. de empresa (personal, ventas, almacén)
almacenamiento más eficiente: elementos “similares” dentro de una tabla y diferentes entre ellas
Para poder combinar información los datos deben ser relacionales: cada par de tablas están relacionadas mediante identificadores comunes llamados claves
Primaria (o interna): identifican de forma única cada observación en una tabla. Puede ser una sola variable o múltiples
Secundaria (o externa): señala a la clave primaria de otra tabla
# Datos de ejemplo para ilustrar
df1 <- tibble(id = 1:3, valor_x = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2, 4), valor_y = c("X", "Y", "Z"))
# inner_join: solo coincidencias
inner_join(df1, df2, by = "id") # resultado: 1, 2
# left_join: todas de la izquierda
left_join(df1, df2, by = "id") # resultado: 1, 2, 3 (3 con NA)
# right_join: todas de la derecha
right_join(df1, df2, by = "id") # resultado: 1, 2, 4 (4 con NA)
# full_join: todas de ambas
full_join(df1, df2, by = "id") # resultado: 1, 2, 3, 4Objetivo: Análisis completo con información de todas las tablas
# Crear dataset completo para análisis
ventas_completo <- ventas %>%
# Información de productos
left_join(productos %>% select(id_producto, nombre_producto, id_categoria, precio, costo),
by = "id_producto") %>%
# Información de categorías
left_join(categorias, by = "id_categoria") %>%
# Información de tiendas
left_join(tiendas %>% select(id_tienda, nombre_tienda, region, tamaño_m2),
by = "id_tienda") %>%
# Información de clientes
left_join(clientes %>% select(id_cliente, programa_fidelidad, fecha_registro),
by = "id_cliente") %>%
# Información de empleados
left_join(empleados %>% select(id_empleado, puesto),
by = "id_empleado")
# Ahora podemos hacer análisis complejos
glimpse(ventas_completo)Análisis Enriquecido:
# Rentabilidad por categoría y región
analisis_rentabilidad <- ventas_completo %>%
mutate(
margen_venta = precio - costo,
rentabilidad = margen_venta * cantidad
) %>%
group_by(nombre_categoria, region) %>%
summarize(
num_ventas = n(),
ingresos = sum(total),
rentabilidad_total = sum(rentabilidad, na.rm = TRUE),
margen_porcentaje = round(mean(margen_venta / precio * 100, na.rm = TRUE), 1),
.groups = "drop"
) %>%
arrange(desc(rentabilidad_total))
head(analisis_rentabilidad, 10)Caso de Uso: Performance por región y mes
# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año, mes) %>%
summarize(
ventas_totales = n(),
ingresos = sum(total),
ticket_promedio = mean(total),
.groups = "drop"
) %>%
arrange(region, año, mes)
head(performance_regional, 10)Caso de Uso: Performance por región y mes
# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año, mes) %>%
summarize(
ventas_totales = n(),
ingresos = sum(total),
ticket_promedio = mean(total),
.groups = "drop"
) %>%
arrange(region, año, mes)
head(performance_regional, 10)# Comparación mensual por región
crecimiento_mensual <- performance_regional %>%
group_by(region, mes) %>%
arrange(año) %>%
mutate(
ingresos_año_anterior = lag(ingresos),
crecimiento_absoluto = ingresos - ingresos_año_anterior,
crecimiento_porcentual = round(
(ingresos - ingresos_año_anterior) / ingresos_año_anterior * 100, 1
)
) %>%
filter(!is.na(crecimiento_porcentual))
# Regiones con mayor crecimiento
crecimiento_mensual %>%
filter(año == 2023) %>%
group_by(region) %>%
summarize(crecimiento_promedio = mean(crecimiento_porcentual, na.rm = TRUE)) %>%
arrange(desc(crecimiento_promedio))semi_join: Mantener filas que tienen coincidencia
anti_join: Mantener filas que NO tienen coincidencia
# Clientes registrados pero sin compras
clientes_sin_compras <- clientes %>%
anti_join(ventas, by = "id_cliente")
nrow(clientes_sin_compras) # Oportunidad de marketing
# Productos en catálogo pero nunca vendidos
productos_sin_vender <- productos %>%
anti_join(ventas, by = "id_producto") %>%
filter(activo == TRUE) # y que estén activos
nrow(productos_sin_vender) # ¿Eliminar del catálogo?# Crear tabla de objetivos mensuales por tienda
objetivos <- tibble(
id_tienda = rep(1:12, each = 12),
mes = rep(1:12, times = 12),
objetivo_ingresos = runif(144, 30000, 80000)
)
# Comparar ventas reales vs objetivos
comparacion_objetivos <- ventas %>%
group_by(id_tienda, mes) %>%
summarize(ingresos_reales = sum(total), .groups = "drop") %>%
left_join(objetivos, by = c("id_tienda", "mes")) %>%
mutate(
diferencia = ingresos_reales - objetivo_ingresos,
cumplimiento_pct = round(ingresos_reales / objetivo_ingresos * 100, 1),
cumple_objetivo = cumplimiento_pct >= 100
)
# Tiendas que más superan objetivos
comparacion_objetivos %>%
filter(cumple_objetivo) %>%
group_by(id_tienda) %>%
summarize(
meses_cumplidos = n(),
exceso_promedio = mean(diferencia)
) %>%
arrange(desc(meses_cumplidos))Contexto: Gerente de operaciones preocupado por devoluciones
Preguntas: 1. ¿Qué productos tienen más devoluciones? 2. ¿Hay patrones por tienda o categoría? 3. ¿Impacto financiero de las devoluciones? 4. ¿Recomendaciones accionables?
# Visión general de devoluciones
glimpse(devoluciones)
# Estadísticas básicas
devoluciones %>%
summarize(
total_devoluciones = n(),
tasa_devolucion = n() / nrow(ventas) * 100,
reembolso_total = sum(reembolso),
reembolso_promedio = mean(reembolso),
dias_promedio = mean(as.numeric(fecha_devolucion -
ventas$fecha[match(id_venta, ventas$id_venta)]), na.rm = TRUE)
)# Distribución de motivos
analisis_motivos <- devoluciones %>%
group_by(motivo) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
) %>%
mutate(
pct_devoluciones = round(num_devoluciones / sum(num_devoluciones) * 100, 1),
pct_reembolso = round(reembolso_total / sum(reembolso_total) * 100, 1)
) %>%
arrange(desc(num_devoluciones))
analisis_motivos
# Visualización
ggplot(analisis_motivos, aes(x = reorder(motivo, num_devoluciones),
y = num_devoluciones)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(title = "Devoluciones por Motivo",
x = "Motivo", y = "Número de Devoluciones") +
theme_minimal()# Unir devoluciones con ventas y productos
productos_devueltos <- devoluciones %>%
left_join(ventas %>% select(id_venta, id_producto, id_tienda),
by = "id_venta") %>%
left_join(productos %>% select(id_producto, nombre_producto, id_categoria),
by = "id_producto") %>%
left_join(categorias %>% select(id_categoria, nombre_categoria),
by = "id_categoria")
# Top productos con más devoluciones
productos_problema <- productos_devueltos %>%
group_by(id_producto, nombre_producto, nombre_categoria) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
) %>%
arrange(desc(num_devoluciones)) %>%
head(20)
productos_problema
# Calcular tasa de devolución por producto
tasa_devolucion_producto <- ventas %>%
group_by(id_producto) %>%
summarize(
veces_vendido = n(),
.groups = "drop"
) %>%
left_join(
devoluciones %>%
left_join(ventas %>% select(id_venta, id_producto), by = "id_venta") %>%
group_by(id_producto) %>%
summarize(veces_devuelto = n(), .groups = "drop"),
by = "id_producto"
) %>%
mutate(
veces_devuelto = replace_na(veces_devuelto, 0),
tasa_devolucion = round(veces_devuelto / veces_vendido * 100, 2)
) %>%
filter(veces_vendido >= 10) %>% # solo productos con suficientes ventas
arrange(desc(tasa_devolucion))
head(tasa_devolucion_producto, 10)# Devoluciones por tienda
devoluciones_tienda <- productos_devueltos %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
by = "id_tienda") %>%
group_by(id_tienda, nombre_tienda, region) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
)
# Calcular tasa de devolución por tienda
ventas_por_tienda <- ventas %>%
group_by(id_tienda) %>%
summarize(num_ventas = n(), .groups = "drop")
comparacion_tiendas <- ventas_por_tienda %>%
left_join(devoluciones_tienda, by = "id_tienda") %>%
mutate(
num_devoluciones = replace_na(num_devoluciones, 0),
tasa_devolucion = round(num_devoluciones / num_ventas * 100, 2)
) %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
by = "id_tienda") %>%
arrange(desc(tasa_devolucion))
comparacion_tiendas
# Por categoría
devoluciones_categoria <- productos_devueltos %>%
group_by(nombre_categoria) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
motivo_principal = names(sort(table(motivo), decreasing = TRUE))[1],
.groups = "drop"
) %>%
arrange(desc(num_devoluciones))
devoluciones_categoria# Impacto total
impacto_financiero <- ventas %>%
summarize(
ingresos_brutos = sum(total),
.groups = "drop"
) %>%
mutate(
reembolsos = sum(devoluciones$reembolso),
ingresos_netos = ingresos_brutos - reembolsos,
tasa_devolucion_financiera = round(reembolsos / ingresos_brutos * 100, 2)
)
impacto_financiero
# Impacto por período
impacto_temporal <- ventas %>%
mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
group_by(año_mes) %>%
summarize(
ingresos_brutos = sum(total),
.groups = "drop"
) %>%
left_join(
devoluciones %>%
left_join(ventas %>% select(id_venta, año, mes), by = "id_venta") %>%
mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
group_by(año_mes) %>%
summarize(reembolsos = sum(reembolso), .groups = "drop"),
by = "año_mes"
) %>%
mutate(
reembolsos = replace_na(reembolsos, 0),
ingresos_netos = ingresos_brutos - reembolsos,
tasa_devolucion = round(reembolsos / ingresos_brutos * 100, 2)
) %>%
arrange(año_mes)
# Visualizar tendencia
ggplot(impacto_temporal, aes(x = año_mes, y = tasa_devolucion, group = 1)) +
geom_line(color = "red", size = 1) +
geom_point() +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
labs(title = "Evolución de la Tasa de Devolución",
x = "Período", y = "Tasa de Devolución (%)")# Crear informe ejecutivo
informe_devoluciones <- list(
resumen = devoluciones %>%
summarize(
total_devoluciones = n(),
tasa_global = round(n() / nrow(ventas) * 100, 2),
costo_total = sum(reembolso)
),
productos_criticos = tasa_devolucion_producto %>%
filter(tasa_devolucion > 10) %>%
head(10),
tiendas_problema = comparacion_tiendas %>%
filter(tasa_devolucion > 7) %>%
head(5),
categorias_riesgo = devoluciones_categoria %>%
head(3)
)
# Mostrar informe
informe_devolucionesRecomendaciones:
Error 3: No verificar claves duplicadas en joins
# PROBLEMA: claves duplicadas pueden multiplicar filas
df1 <- tibble(id = c(1, 1, 2), valor = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2), info = c("X", "Y"))
resultado <- left_join(df1, df2, by = "id")
nrow(resultado) # Esperamos 3, tenemos 3 (pero revisar lógica)
# VERIFICAR antes de hacer join
df1 %>% count(id) %>% filter(n > 1) # ids duplicados
df2 %>% count(id) %>% filter(n > 1) # ids duplicados
# SOLUCIÓN: decidir qué hacer con duplicados
df1_unico <- df1 %>% distinct(id, .keep_all = TRUE)Error 4: Orden de operaciones incorrecto
# USO EFICIENTE: filtrar primero, luego unir
ventas_2023 <- ventas %>%
filter(año == 2023) %>% # reduce tamaño
left_join(productos, by = "id_producto")
# USO INEFICIENTE: unir todo, luego filtrar
ventas_2023_lento <- ventas %>%
left_join(productos, by = "id_producto") %>%
filter(año == 2023)
# SELECCIONAR SOLO COLUMNAS NECESARIAS
productos_minimo <- productos %>%
select(id_producto, nombre_producto, precio)
ventas_join <- ventas %>%
left_join(productos_minimo, by = "id_producto")# Distribución de ventas
ggplot(ventas, aes(x = total)) +
geom_histogram(bins = 50, fill = "steelblue", alpha = 0.7) +
labs(title = "Distribución de Ventas",
x = "Importe Total (€)", y = "Frecuencia") +
theme_minimal()
# Ventas por día de la semana
ventas_dia <- ventas %>%
group_by(dia_semana) %>%
summarize(
num_ventas = n(),
ingresos = sum(total),
ticket_promedio = mean(total)
)
ggplot(ventas_dia, aes(x = dia_semana, y = ingresos)) +
geom_col(fill = "coral") +
labs(title = "Ingresos por Día de la Semana",
x = "Día", y = "Ingresos (€)") +
theme_minimal()
# Evolución temporal
ventas_mensual <- ventas %>%
group_by(año, mes) %>%
summarize(ingresos = sum(total), .groups = "drop") %>%
mutate(periodo = paste(año, sprintf("%02d", mes), sep = "-"))
ggplot(ventas_mensual, aes(x = periodo, y = ingresos, group = 1)) +
geom_line(color = "darkgreen", size = 1) +
geom_point() +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
labs(title = "Evolución de Ingresos Mensuales",
x = "Período", y = "Ingresos (€)")# Top 10 tiendas por ingresos
top_tiendas <- ventas %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda), by = "id_tienda") %>%
group_by(nombre_tienda) %>%
summarize(ingresos = sum(total), .groups = "drop") %>%
arrange(desc(ingresos)) %>%
head(10)
ggplot(top_tiendas, aes(x = reorder(nombre_tienda, ingresos), y = ingresos)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(title = "Top 10 Tiendas por Ingresos",
x = "Tienda", y = "Ingresos (€)") +
theme_minimal()
# Comparación entre regiones
ventas_region <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año) %>%
summarize(ingresos = sum(total), .groups = "drop")
ggplot(ventas_region, aes(x = factor(año), y = ingresos, fill = region)) +
geom_col(position = "dodge") +
labs(title = "Ingresos por Región y Año",
x = "Año", y = "Ingresos (€)", fill = "Región") +
theme_minimal()Datos Ordenados
Código Legible y reproducible
Scripts ordenados y comentados
Encadenar operacion con |> en un paso por línea
Documentar decisiones de limpieza
Nombres descriptivos de variables
Guardar datos procesados
Eficiencia:
Filtrar temprano, unir tarde
Seleccionar solo columnas necesarias
Verificar claves antes de joins
Inspeccionar resultados intermedios
Usando los datos de RetailCorp: